勿忘IT苦人多,本文會使用JAVA在Android Studio上撰寫,Server使用JavaScript在VScode上撰寫
需要一些android studio的基礎,相關環境方面的問題就不贅述,讓我們開始吧
前置作業:
1.安裝Java
2.安裝Android Studio
3.安裝Node.js
4.安裝VScode
5.安裝Nox,買Android手機(X
創建一個專案(new project),選擇empty Activity -> Next -> Finish,這樣就創建了一個空白的專案。
在左邊打開Gradle Scripts這個資料夾,選取 build.gradle (Module: app)
我們要在這邊引入我們想要使用的功能,在dependencies{}裡面新增兩行代碼
implementation 'androidx.recyclerview:recyclerview:1.0.0'
implementation 'com.squareup.okhttp3:okhttp:3.10.0'
然後使用Java8的環境
在android{}裡面新增以下代碼
compileOptions {
sourceCompatibility '1.8'
targetCompatibility '1.8'
}
這樣就修改完成了
文件上方會有一串提示文字,點選" Sync Now ",也可以點選綠色小槌子rebuild
再來去app -> manifests -> AndroidManifest.xml 增加權限
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
INTERNET就是網路,READ_EXTERNAL_STORAGE是我們要傳遞圖片使用的,去讀取檔案的權限
在"application"內部新增一串代碼
android:usesCleartextTraffic="true"
這是用來支持app可以接受未加密的請求
接下來就是使用者界面了
主畫面,輸入自己的名字進入聊天室
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/editText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:layout_marginStart="20dp"
android:layout_marginTop="20dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="20dp"
android:background="@drawable/edit_text_design"
android:hint="輸入您的名字"
android:padding="10dp"
android:textSize="16sp"
tools:ignore="MissingConstraints" />
<Button
android:id="@+id/enterBtn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/editText"
android:layout_marginStart="24dp"
android:layout_marginTop="24dp"
android:layout_marginEnd="24dp"
android:background="@color/colorPrimaryDark"
android:text="進入聊天"
android:textColor="#ffffff" />
</RelativeLayout>
文字區域設計 放在drawable裡面
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#cccccc" />
<stroke android:color="#808080"
android:width="1dp" />
<corners android:radius="5dp"/>
</shape>
進入MainActivity.java
這裡主要是進入房間這個按鈕我們希望可以把"名字"這個資訊傳入聊天室裡面
創建一個Empty Activity 命名為 ChatActivity
package com.example.test0513;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.widget.EditText;
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
if(ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED){
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 10);
}
EditText editText = findViewById(R.id.editText);
findViewById(R.id.enterBtn)
.setOnClickListener(v -> {
Intent intent = new Intent(this,ChatActivity.class);
intent.putExtra("name",editText.getText().toString());
startActivity(intent);
});
}
}
在創建完ChatActivity之後會出現一個activity_chat.xml
建構聊天畫面
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ChatActivity">
<androidx.recyclerview.widget.RecyclerView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@id/messageEdit"
android:id="@+id/recyclerView"/>
<EditText
android:id="@+id/messageEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:layout_toStartOf="@id/sendBtn"
android:background="@drawable/edit_text_design"
android:hint="Message..."
android:padding="8dp"
android:textSize="16sp" />
<TextView
android:id="@+id/sendBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:layout_marginBottom="16dp"
android:padding="10dp"
android:text="Send"
android:visibility="invisible"
android:textColor="@color/colorPrimary" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_image_black_24dp"
android:tint="@color/colorPrimary"
android:padding="8dp"
android:layout_alignParentBottom="true"
android:layout_toEndOf="@id/messageEdit"
android:layout_marginBottom="16dp"
android:id="@+id/pickImgBtn"
android:layout_alignParentEnd="true" />
</RelativeLayout>
接下來都是新增Layout,在Layout資料夾點右鍵 -> New -> Layout Resource File
新增四個Layout 分別叫
item_received_message.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".MainActivity">
<TextView
android:id="@+id/nameTxt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:text="Name"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/nameTxt"
android:background="@color/colorPrimary"
android:padding="8dp"
android:textColor="#ffffff"
android:layout_marginEnd="64dp"
android:layout_marginBottom="4dp"
android:textSize="16sp"
android:id="@+id/receivedTxt"
android:text="Hello my name is yancehn"/>
</RelativeLayout>
item_received_photo.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Name"
android:id="@+id/nameTxt"
android:layout_margin="4dp"
android:textStyle="bold"/>
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="64dp"
android:layout_marginStart="4dp"
android:layout_marginBottom="4dp"
android:layout_below="@id/nameTxt"
android:id="@+id/imageView"
android:src="@drawable/ic_image_black_24dp"/>
</RelativeLayout>
item_send_message.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:layout_marginStart="64dp"
android:padding="8dp"
android:textSize="16sp"
android:background="#cccccc"
android:text="Hello"
android:id="@+id/sentTxt"/>
</RelativeLayout>
item_sent_image.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".MainActivity">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_marginTop="4dp"
android:layout_marginBottom="4dp"
android:layout_marginStart="64dp"
android:padding="8dp"
android:id="@+id/imageView"
android:src="@drawable/ic_image_black_24dp"/>
</RelativeLayout>
這樣基本的使用者介面就完成了
在完成使用者介面之後,接下來我們要來跟伺服器建立連線,我們會使用okhttp3,他是一個網路請求的開源專案
伺服器位址我們可以先用"ws://echo.websocket.org" 這個位址會回傳你給他的內容(你跟他說hello 他就跟你說hello),可以用來檢視自己發的內容是不是正確的。
打開ChatActivity.java
package com.example.test0513;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.RecyclerView;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
public class ChatActivity extends AppCompatActivity{
private String name;
private String SERVER_PATH = "ws://echo.websocket.org";
private WebSocket webSocket;
private EditText messageEdit;
private View sendBtn, pickImgBtn;
private RecyclerView recyclerView;
private MessageAdapter messageAdapter;//先別管我
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_chat);
name = getIntent().getStringExtra("name");
initiateSocketConnection();
}
private void initiateSocketConnection() {
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder().url(SERVER_PATH).build();
webSocket = client.newWebSocket(request, new SocketListener());
}
private class SocketListener extends WebSocketListener{
}
}
點在WebSocketListener上按Ctrl+O ,override兩個方法,onOpen是當連接成功的時候會調用的方法
onMessage(websocket, string)是當收到string內容的時候會執行的方法,點選這兩個方法按下OK
private class SocketListener extends WebSocketListener{
@Override
public void onOpen(WebSocket webSocket, Response response) {
super.onOpen(webSocket, response);
}
@Override
public void onMessage(WebSocket webSocket, String text) {
super.onMessage(webSocket, text);
}
}
現在你的SoketListener會長這樣,然後我們希望在連接成功的時候我們可以獲得一條訊息
所以在onOpen裡面
runOnUiThread(() -> {
Toast.makeText(ChatActivity.this, "Socket Connection Successful",
Toast.LENGTH_SHORT).show();
initializeView();
});
順帶一提,這裡的寫法沒有Java8是不支援的,然後如果連接成功我們希望初始化界面,新增一個initializeView方法
在那上面按下 Alt+Enter + Enter新增方法(在ChatActivity新增)
在initializeView這個方法裡面我們先把該找的Id找好讓他們找到歸宿
然後我們希望我們的輸入介面是這樣的,如果使用者沒輸入內容的時候我們希望按鈕變成發送圖片,如果使用者輸入文字按鈕就變成發送按鈕所以在這邊新增一個addTextChangedListener,他會在每次使用者打字或是刪減時被觸發
private void initializeView() {
messageEdit = findViewById(R.id.messageEdit);
sendBtn = findViewById(R.id.sendBtn);
pickImgBtn = findViewById(R.id.pickImgBtn);
recyclerView = findViewById(R.id.recyclerView);
messageEdit.addTextChangedListener(this);
messageAdapter = new MessageAdapter(getLayoutInflater());//先別管我
recyclerView.setAdapter(messageAdapter);//先別管我
recyclerView.setLayoutManager(new LinearLayoutManager(this));//先別管我
}
在this上面按Alt+Enter,選擇讓ChatActivity實作TextWatcher,按下OK會實作三個方法,在afterTextChanged這裡去判斷是要顯示哪種Button(afterTextChanged是三個的其中一個)
@Override
public void afterTextChanged(Editable s) {
String string = s.toString().trim();
if(string.isEmpty()){
resetMessageEdit();
}else{
sendBtn.setVisibility(View.VISIBLE);
pickImgBtn.setVisibility(View.INVISIBLE);
}
}
private void resetMessageEdit() {
messageEdit.removeTextChangedListener(this);
messageEdit.setText("");
sendBtn.setVisibility(View.INVISIBLE);
pickImgBtn.setVisibility(View.VISIBLE);
messageEdit.addTextChangedListener(this);
}
再來我們要賦予按鈕功能,回到initializeView這個方法
private void initializeView() {
...略
sendBtn.setOnClickListener(v -> {
JSONObject jsonObject = new JSONObject();
try {
jsonObject.put("name", name);
jsonObject.put("message", messageEdit.getText().toString());
webSocket.send(jsonObject.toString());
jsonObject.put("isSent", true);
messageAdapter.addItem(jsonObject);//先別管我
resetMessageEdit();
} catch (JSONException e) {
e.printStackTrace();
}
});
}
在這裡,如果使用者按了發送扭,我們就把他的名字還有他輸入的內容發送到伺服器,並把輸入的內容清空
接下來就是獲取圖片的按鈕
private void initializeView() {
...略
pickImgBtn.setOnClickListener(v -> {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("image/*");
startActivityForResult(Intent.createChooser(intent, "Pick image"),
IMAGE_REQUEST_ID);
});
}
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == IMAGE_REQUEST_ID && resultCode == RESULT_OK) {
try {
InputStream is = getContentResolver().openInputStream(data.getData());
Bitmap image = BitmapFactory.decodeStream(is);
sendImage(image);
} catch (FileNotFoundException | JSONException e) {
e.printStackTrace();
}
}
}
private void sendImage(Bitmap image) throws JSONException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
image.compress(Bitmap.CompressFormat.JPEG, 50, outputStream);
String base64String = android.util.Base64.encodeToString(outputStream.toByteArray(),
Base64.DEFAULT);
JSONObject jsonObject = new JSONObject();
jsonObject.put("name", name);
jsonObject.put("image", base64String);
webSocket.send(jsonObject.toString());
jsonObject.put("isSent", true);
messageAdapter.addItem(jsonObject);//先別管我
}
這裡的IMAGE_REQUEST_ID可以設置隨意的數字,當使用者點選圖片按扭的時候,會跳轉到檔案選擇畫面,
這段代碼會把使用者選取的點陣圖片壓縮並轉成Base64編碼發送,這裡我們是用android的Base64類別
回到SocketListener的onMessage這裡我們談過,是當接收到消息的時候會執行的方法
@Override
public void onMessage(okhttp3.WebSocket webSocket, String text) {
super.onMessage(webSocket, text);
runOnUiThread(() -> {
try {
JSONObject jsonObject = new JSONObject(text);
jsonObject.put("isSent", false);
messageAdapter.addItem(jsonObject);//先別管我
} catch (JSONException e) {
e.printStackTrace();
}
});
}
在這裡我們把收到的字串內容標記 "isSent" 為false,用來區別這個內容不是自己發出的
新增一個新的Java class,命名為MessageAdapter,並繼承RecyclerView.Adapter,按下Alt+Enter
實作所有方法,我們先前創作了四個layout分別是自己發送的文字內容,自己發送的圖片,與別人發的文字和圖片
根據不同的情況在使用者介面要放置不同的layout
package com.example.test0513;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
public class MessageAdapter extends RecyclerView.Adapter {
private final int TYPE_MESSAGE_SENT = 0;
private final int TYPE_MESSAGE_RECEIVED = 1;
private final int TYPE_IMAGE_SENT = 2;
private final int TYPE_IMAGE_RECEIVED = 3;
private LayoutInflater inflater;
private List<JSONObject> messages = new ArrayList<>();
public MessageAdapter(LayoutInflater inflater){
this.inflater = inflater;
}
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return null;
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
}
@Override
public int getItemCount() {
return 0;
}
}
所以我們需要用到LayoutInflater,創建一個List來放置所有的聊天內容,
接下來我們要創建四個ViewHolder去控制四個layout的內容
private class SentMessageHolder extends RecyclerView.ViewHolder {
TextView messageTxt;
public SentMessageHolder(@NonNull View itemView) {
super(itemView);
messageTxt = itemView.findViewById(R.id.sentTxt);
}
}
private class SentImageHolder extends RecyclerView.ViewHolder{
ImageView imageView;
public SentImageHolder(@NonNull View itemView) {
super(itemView);
imageView = itemView.findViewById(R.id.imageView);
}
}
private class ReceivedMessageHolder extends RecyclerView.ViewHolder{
TextView nameTxt, messageTxt;
public ReceivedMessageHolder(@NonNull View itemView) {
super(itemView);
nameTxt = itemView.findViewById(R.id.nameTxt);
messageTxt = itemView.findViewById(R.id.receivedTxt);
}
}
private class ReceivedImageHolder extends RecyclerView.ViewHolder{
ImageView imageView;
TextView nameTxt;
public ReceivedImageHolder(@NonNull View itemView) {
super(itemView);
imageView = itemView.findViewById(R.id.imageView);
nameTxt = itemView.findViewById(R.id.nameTxt);
}
}
然後創建一個getItemViewType方法去判斷是哪一種情況要用哪一個Layout
如果isSent是true就是自己發送的,如果裡面有message就是文字內容
@Override
public int getItemViewType(int position) {
JSONObject message = messages.get(position);
try {
if(message.getBoolean("isSent")){
if(message.has("message")){
return TYPE_MESSAGE_SENT;
}else{
return TYPE_IMAGE_SENT;
}
}else{
if(message.has("message")){
return TYPE_MESSAGE_RECEIVED;
}else{
return TYPE_IMAGE_RECEIVED;
}
}
} catch (JSONException e) {
e.printStackTrace();
}
return -1;
}
再來修改getItemCount這個方法的內容為
@Override
public int getItemCount() {
return messages.size();
}
來獲取有幾條訊息
之後讓我們看到onCreateViewHolder這個方法,我們要在這裡套入要用的Layout
並把這些Layout內容放到我們先前做的ViewHolder裡面
@NonNull
@Override
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view;
switch(viewType){
case TYPE_MESSAGE_SENT:
view = inflater.inflate(R.layout.item_send_message, parent, false);
return new SentMessageHolder(view);
case TYPE_MESSAGE_RECEIVED:
view = inflater.inflate(R.layout.item_received_message, parent, false);
return new ReceivedMessageHolder(view);
case TYPE_IMAGE_SENT:
view = inflater.inflate(R.layout.item_sent_image, parent, false);
return new SentImageHolder(view);
case TYPE_IMAGE_RECEIVED:
view = inflater.inflate(R.layout.item_received_photo, parent, false);
return new ReceivedImageHolder(view);
}
return null;
}
最後就是在onBindViewHolder放入我們要這些Layout顯示的內容
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
JSONObject message = messages.get(position);
try {
if(message.getBoolean("isSent")){
if(message.has("message")){
SentMessageHolder messageHolder = (SentMessageHolder) holder;
messageHolder.messageTxt.setText(message.getString("message"));
}else{
SentImageHolder imageHolder = (SentImageHolder) holder;
Bitmap bitmap = getBitmapFromString(message.getString("image"));
imageHolder.imageView.setImageBitmap(bitmap);
}
}else{
if(message.has("message")){
ReceivedMessageHolder messageHolder = (ReceivedMessageHolder) holder;
messageHolder.nameTxt.setText(message.getString("name"));
messageHolder.messageTxt.setText(message.getString("message"));
}else{
ReceivedImageHolder imageHolder = (ReceivedImageHolder) holder;
imageHolder.nameTxt.setText(message.getString("name"));
Bitmap bitmap = getBitmapFromString(message.getString("image"));
imageHolder.imageView.setImageBitmap(bitmap);
}
}
} catch (JSONException e) {
e.printStackTrace();
}
}
private Bitmap getBitmapFromString(String image) {
byte[] bytes = Base64.decode(image, Base64.DEFAULT);
return BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
}
我們再新增一個方法,在有新的聊天內容的時候調用這個方法,把內容放到我們的messages裡面
並告知Adapter內部的內容改變了
public void addItem(JSONObject jsonObject){
messages.add(jsonObject);
notifyDataSetChanged();
}
這樣客戶端的部分就完成了,開起來做測試
現在是輸入甚麼文字就會收到甚麼文字,圖片也是一樣
在搜尋列執行cmd -> npm install websocket
並打上ipconfig/all去查看自己的ip 把SERVER_PATH的內容換成
ws://自己的IP:3000
再把下面的代碼放在VScode上運行下
就可以多人在同一個聊天室進行聊天了
const SocketServer = require('websocket').server;
const http = require('http');
const port = 3000;
const server = http.createServer((req, res) => {});
server.listen(port, () => {
console.log(`Listening on port ${port}...`);
});
wsServer = new SocketServer({httpServer:server});
const connections = [];
wsServer.on('request', (req) => {
const connection = req.accept();
console.log('new connection');
connections.push(connection);
connection.on('message', (mes) => {
connections.forEach(element => {
if(element != connection)
element.sendUTF(mes.utf8Data);
});
});
connection.on('close', (resCode, des) => {
console.log('connection closed');
connections.splice(connections.indexOf(connection), 1);
});
});
您好,程式的部分完全照著您操作,
卻發生了點擊發送按鈕無法傳送,
另外 UI 畫面也和您的不太一樣,
我的部分 Send 跟 Img 兩個是分上下的,
方便請教您問題會是什麼嗎?